#!/usr/bin/python3

# Copyright (c) 2020-2021 Fpemud <fpemud@sina.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.


import os
import json
import platform
import functools
import importlib
from .errors import ConfigError
from .const import MachineType, ComponentType, DeviceType, ConnectorType, OfflineReason
from .prototypes import Machine, Component, Device, Connector


class VendorUtil:

    @functools.cache
    @staticmethod
    def getVendorNames():
        selfDir = os.path.dirname(os.path.realpath(__file__))
        vendorsDir = os.path.join(selfDir, "vendors")
        ret = [x for x in os.listdir(vendorsDir) if not x.startswith("_")]      # excludes "_unknown" and "__init__.py"
        assert all([os.path.isdir(os.path.join(vendorsDir, x)) for x in ret])
        ret.sort()
        return ret

    @functools.cache
    @staticmethod
    def getVendorImpl(vendorName):
        vendorDir = None
        if True:
            selfDir = os.path.dirname(os.path.realpath(__file__))
            realVendorName = vendorName if vendorName != "unknown" else "_unknown"
            vendorDir = os.path.join(selfDir, "vendors", realVendorName)

        indexDict = None
        try:
            with open(os.path.join(vendorDir, "__index__.json")) as f:
                indexDict = json.load(f)
        except FileNotFoundError:
            raise ValueError('invalid vendor "%s"' % (vendorName))

        from .vendor import VendorImpl
        return VendorImpl(realVendorName, indexDict)


class VendorImpl:

    class ProbeFailed(Exception):
        pass

    def __init__(self, realVendorName, indexDict):
        self._realVendorName = realVendorName

        self._id = indexDict["vendor_id"]

        self._name = indexDict["vendor_name"]

        self._products = {
            "machines": indexDict.get("machines", []),
            "components": indexDict.get("components", []),
            "devices": indexDict.get("devices", []),
        }

        modelSet = set()
        for info in self._products["machines"]:
            _assertMachineInfo(info, modelSet)
            modelSet.add(info["model"])

        modelSet = set()
        for info in self._products["components"]:
            _assertComponentInfo(info, modelSet)

        modelSet = set()
        for info in self._products["devices"]:
            _assertDeviceInfo(info, modelSet)

    def get_id(self):
        return self._id

    def get_name(self):
        return self._name

    def get_all_products(self):
        ret = []
        for category, infoList in self._products:
            for info in infoList:
                ret.append((category, info["model"]))
        return ret

    def get_machine_info(self, model):
        for info in self._products["machines"]:
            if info["model"] == model:
                return info
        return None

    def get_component_info(self, model):
        for info in self._products["components"]:
            if info["model"] == model:
                return info
        return None

    def get_device_info(self, model):
        for info in self._products["devices"]:
            if info["model"] == model:
                return info
        return None

    def probe_machine(self, sys_snapshot, model=None):
        assert model is None    # FIXME: it is complex to filter by model, must create object and relase it if id not match

        for machineInfo in self._products["machines"]:
            clazz = self._getClass(MachineImpl, machineInfo["machine_class"])
            try:
                return clazz(sys_snapshot, self, machineInfo)
            except self.ProbeFailed:
                pass
        return None

    def probe_machine_components(self, sys_snapshot, model=None, id=None):
        assert id is None   # FIXME: it is complex to filter by id, must create object and relase it (clear use_component) if id not match

        ret = []
        componentTypeIdSet = set()

        for componentInfo in self._products["components"]:
            if model is not None and componentInfo["model"] != model:
                continue

            # probe component
            if "component_class" in componentInfo:
                clazz = self._getClass(ComponentImpl, componentInfo["component_class"])
                while True:
                    try:
                        obj = clazz(sys_snapshot, self, componentInfo, componentInfo)
                        if (obj.get_type(), obj.get_id()) in componentTypeIdSet:
                            raise SystemError("duplicate component type %s id %s" % (obj.get_type(), obj.get_id()))
                        if "dumb_component_class" not in componentInfo:
                            assert obj._getSaveInfo() is None
                        ret.append(obj)
                        componentTypeIdSet.add((obj.get_type(), obj.get_id()))
                    except self.ProbeFailed:
                        # keep probing until failure, which means no more component left
                        break

            # use all dumb components
            for c in sys_snapshot.dumb.query_components([lambda x: x.data["vendor"] == self.get_id() and x.data["model"] == componentInfo["model"]]):
                if "component_class" in componentInfo:
                    if "dumb_component_class" in componentInfo:
                        clazz = self._getClass(ComponentImpl, componentInfo["dumb_component_class"])
                    else:
                        raise ConfigError("invalid dumb component found")
                else:
                    clazz = ComponentImpl
                sys_snapshot.dumb.use_component(c)
                obj = clazz(None, self, componentInfo, c.data)
                if (obj.get_type(), obj.get_id()) in componentTypeIdSet:
                    raise SystemError("duplicate component type %s id %s" % (obj.get_type(), obj.get_id()))
                ret.append(obj)
                componentTypeIdSet.add((obj.get_type(), obj.get_id()))

            # use all saved components
            savedComponentTypeIdSet = set()
            for c in sys_snapshot.saved.query_components([lambda x: x.data["vendor"] == self.get_id() and x.data["model"] == componentInfo["model"]]):
                if "dumb_component_class" not in componentInfo:
                    raise ConfigError("invalid saved component found")
                clazz = self._getClass(ComponentImpl, componentInfo["dumb_component_class"])
                sys_snapshot.saved.use_component(c)
                obj = clazz(None, self, componentInfo, c.data)
                if (obj.get_type(), obj.get_id()) in savedComponentTypeIdSet:
                    raise SystemError("duplicate saved component type %s id %s" % (obj.get_type(), obj.get_id()))
                if (obj.get_type(), obj.get_id()) in componentTypeIdSet:
                    # the saved component has been replaced
                    continue
                ret.append(obj)
                savedComponentTypeIdSet.add((obj.get_type(), obj.get_id()))

        return ret

    def probe_machine_devices(self, sys_snapshot, model=None, id=None):
        pass

    def probe_component_devices(self, sys_snapshot, componnet_id, model=None, id=None):
        pass

    def _getClass(self, baseClass, className):
        assert baseClass in [MachineImpl, ComponentImpl, DeviceImpl, ConnectorImpl]

        if className == "default":
            return baseClass

        try:
            # className example: c3271u.Component
            idx = className.rindex(".")
            relativeModulePath = ".vendors.%s.%s" % (self._realVendorName, className[:idx])
            className = className[idx+1:]
            ret = getattr(importlib.import_module(relativeModulePath, package=__package__), className)
            assert issubclass(ret, baseClass)
            return ret
        except ModuleNotFoundError:
            assert False


class MachineImpl(Machine):

    ProbeFailed = VendorImpl.ProbeFailed

    def __init__(self, sysSnapshot, vendorImpl, machineInfo, serial=None, machineType=None):
        # create instance
        super().__init__(vendorImpl.get_id(),                                                                       # vendor
                         machineInfo["model"],                                                                      # model
                         serial,                                                                                    # serial
                         machineType if machineType is not None else MachineType(machineInfo["machine_type"]),      # machineType
                         platform.machine())                                                                        # arch

        # add components
        for vendorName in VendorUtil.getVendorNames() + ["unknown"]:
            for c in VendorUtil.getVendorImpl(vendorName).probe_machine_components(sysSnapshot):
                self._addComponent(c)

        # add devices
        for machineDeviceInfo in machineInfo.get("devices", []):
            if "count" in machineDeviceInfo:
                for i in range(0, machineDeviceInfo["count"]):
                    # reference to other deviceInfo
                    # or
                    # inline device
                    if "vendor" in machineDeviceInfo:
                        realVendorImpl = VendorUtil.getVendorImpl(machineDeviceInfo["vendor"])
                        realMachineDeviceInfo = realVendorImpl.get_device_info(machineDeviceInfo["model"])
                        # smart device use a derived class, which should skip the default logic in DeviceImpl (by feeding enough arguments to base class)
                        # dumb device use DeviceImpl class directly, which embeddeds some default logic
                        clazz = realVendorImpl._getClass(DeviceImpl, realMachineDeviceInfo["device_class"]) if "device_class" in realMachineDeviceInfo else DeviceImpl
                        obj = clazz(sysSnapshot, realVendorImpl, self, realMachineDeviceInfo, machineDeviceInfo, idx=i)
                        self._addDevice(obj)
                    else:
                        clazz = vendorImpl._getClass(DeviceImpl, machineDeviceInfo["device_class"]) if "device_class" in machineDeviceInfo else DeviceImpl
                        obj = clazz(sysSnapshot, None, self, None, machineDeviceInfo, idx=i)
                        self._addDevice(obj)
            else:
                # same as above
                if "vendor" in machineDeviceInfo:
                    realVendorImpl = VendorUtil.getVendorImpl(machineDeviceInfo["vendor"])
                    realMachineDeviceInfo = realVendorImpl.get_device_info(machineDeviceInfo["model"])
                    clazz = realVendorImpl._getClass(DeviceImpl, realMachineDeviceInfo["device_class"]) if "device_class" in realMachineDeviceInfo else DeviceImpl
                    obj = clazz(sysSnapshot, realVendorImpl, self, realMachineDeviceInfo, machineDeviceInfo)
                    self._addDevice(obj)
                else:
                    clazz = vendorImpl._getClass(DeviceImpl, machineDeviceInfo["device_class"]) if "device_class" in machineDeviceInfo else DeviceImpl
                    obj = clazz(sysSnapshot, None, self, None, machineDeviceInfo)
                    self._addDevice(obj)

        # add connectors, all connectors are inline
        for machineConnectorInfo in machineInfo.get("connectors", []):
            if "count" in machineConnectorInfo:
                for i in range(0, machineConnectorInfo["count"]):
                    clazz = vendorImpl._getClass(ConnectorImpl, machineConnectorInfo["connector_class"]) if "connector_class" in machineConnectorInfo else ConnectorImpl
                    obj = clazz(sysSnapshot, self, machineConnectorInfo, idx=i)
                    self._addConnector(obj)
            else:
                clazz = vendorImpl._getClass(ConnectorImpl, machineConnectorInfo["connector_class"]) if "connector_class" in machineConnectorInfo else ConnectorImpl
                obj = clazz(sysSnapshot, self, machineConnectorInfo)
                self._addConnector(obj)

        # add property methods
        for funcName, returnValue in machineInfo.get("property_methods", {}).items():
            assert not hasattr(self, funcName)
            setattr(self, funcName, lambda v=returnValue: v)


class ComponentImpl(Component):

    ProbeFailed = VendorImpl.ProbeFailed

    def __init__(self, sysSnapshot, vendorImpl, componentInfo, inlineData, serial=None, id=None, offlineReason=None, saveInfo=None):
        if serial is None:
            serial = inlineData.get("serial", None)

        if id is None:
            id = inlineData.get("id", "default")

        if sysSnapshot is None:
            # this is a dumb component, dumb components need not to be saved
            assert saveInfo is None

        # create instance
        super().__init__(vendorImpl.get_id(), componentInfo["model"], serial,  ComponentType(componentInfo["component_type"]), id, offlineReason, saveInfo)

        # add devices
        # FIXME: for dumb component, we should merge deviceInfo in componentInfo and inlineData
        for componentDeviceInfo in componentInfo.get("devices", []):
            if "count" in componentDeviceInfo:
                for i in range(0, componentDeviceInfo["count"]):
                    # reference to other deviceInfo
                    # or
                    # use inline deviceInfo
                    if "vendor" in componentDeviceInfo:
                        realVendorImpl = VendorUtil.getVendorImpl(componentDeviceInfo["vendor"])
                        realComponentDeviceInfo = realVendorImpl.get_device_info(componentDeviceInfo["model"])
                        # smart device use a derived class, which should skip the default logic in DeviceImpl (by feeding enough arguments to base class)
                        # dumb device use DeviceImpl class directly, which embeddeds some default logic
                        if "device_class" in realComponentDeviceInfo:
                            if sysSnapshot is not None:
                                clazz = realVendorImpl._getClass(DeviceImpl, realComponentDeviceInfo["device_class"])
                            else:
                                clazz = realVendorImpl._getClass(DeviceImpl, realComponentDeviceInfo["dumb_device_class"])
                        else:
                            clazz = DeviceImpl
                        obj = clazz(sysSnapshot, realVendorImpl, self, realComponentDeviceInfo, componentDeviceInfo, idx=i)
                        self._addDevice(obj)
                    else:
                        if "device_class" in componentDeviceInfo:
                            if sysSnapshot is not None:
                                clazz = vendorImpl._getClass(DeviceImpl, componentDeviceInfo["device_class"])
                            else:
                                clazz = vendorImpl._getClass(DeviceImpl, componentDeviceInfo["dumb_device_class"])
                        else:
                            clazz = DeviceImpl
                        obj = clazz(sysSnapshot, None, self, None, componentDeviceInfo, idx=i)
                        self._addDevice(obj)
            else:
                # same as above
                if "vendor" in componentDeviceInfo:
                    realVendorImpl = VendorUtil.getVendorImpl(componentDeviceInfo["vendor"])
                    realComponentDeviceInfo = realVendorImpl.get_device_info(componentDeviceInfo["model"])
                    # smart device use a derived class, which should skip the default logic in DeviceImpl (by feeding enough arguments to base class)
                    # dumb device use DeviceImpl class directly, which embeddeds some default logic
                    if "device_class" in realComponentDeviceInfo:
                        if sysSnapshot is not None:
                            clazz = realVendorImpl._getClass(DeviceImpl, realComponentDeviceInfo["device_class"])
                        else:
                            clazz = realVendorImpl._getClass(DeviceImpl, realComponentDeviceInfo["dumb_device_class"])
                    else:
                        clazz = DeviceImpl
                    obj = clazz(sysSnapshot, realVendorImpl, self, realComponentDeviceInfo, componentDeviceInfo)
                    self._addDevice(obj)
                else:
                    if "device_class" in componentDeviceInfo:
                        if sysSnapshot is not None:
                            clazz = vendorImpl._getClass(DeviceImpl, componentDeviceInfo["device_class"])
                        else:
                            clazz = vendorImpl._getClass(DeviceImpl, componentDeviceInfo["dumb_device_class"])
                    else:
                        clazz = DeviceImpl
                    obj = clazz(sysSnapshot, None, self, None, componentDeviceInfo)
                    self._addDevice(obj)

        # add connectors, all connectors are inline
        # FIXME: for dumb component, we should merge connectorInfo in componentInfo and inlineData
        for componentConnectorInfo in componentInfo.get("connectors", []):
            if "count" in componentConnectorInfo:
                for i in range(0, componentConnectorInfo["count"]):
                    if "connector_class" in componentConnectorInfo:
                        if sysSnapshot is not None:
                            clazz = vendorImpl._getClass(ConnectorImpl, componentConnectorInfo["connector_class"])
                        else:
                            clazz = vendorImpl._getClass(ConnectorImpl, componentConnectorInfo["dumb_connector_class"])
                    else:
                        clazz = ConnectorImpl
                    obj = clazz(sysSnapshot, self, componentConnectorInfo, idx=i)
                    self._addConnector(obj)
            else:
                if "connector_class" in componentConnectorInfo:
                    if sysSnapshot is not None:
                        clazz = vendorImpl._getClass(ConnectorImpl, componentConnectorInfo["connector_class"])
                    else:
                        clazz = vendorImpl._getClass(ConnectorImpl, componentConnectorInfo["dumb_connector_class"])
                else:
                    clazz = ConnectorImpl
                obj = clazz(sysSnapshot, self, componentConnectorInfo)
                self._addConnector(obj)

        # add property methods
        for funcName, returnValue in componentInfo.get("property_methods", {}).items():
            assert not hasattr(self, funcName)
            setattr(self, funcName, lambda v=returnValue: v)


class DeviceImpl(Device):

    ProbeFailed = VendorImpl.ProbeFailed

    def __init__(self, sysSnapshot, vendorImpl, machineOrComponent, deviceInfo, inlineData, idx=None, id=None, offlineReason=None):
        if vendorImpl is not None:
            vendor = vendorImpl.get_id()
            model = deviceInfo["model"]
            deviceType = DeviceType(deviceInfo["device_type"])
            propertyMethods = deviceInfo.get("property_methods", {})
        else:
            # inline device
            assert deviceInfo is None
            vendor = None
            model = None
            deviceType = DeviceType(inlineData["device_type"])
            propertyMethods = inlineData.get("property_methods", {})

        if id is None:
            id = inlineData.get("id", "default")
            if "%i" in id:
                id = id.replace("%i", str(idx))

        if offlineReason is None:
            if isinstance(machineOrComponent, Machine):
                pass
            elif isinstance(machineOrComponent, Component):
                if not machineOrComponent.is_online():
                    offlineReason = OfflineReason.POWER_OFF
            else:
                assert False

        # create instance
        super().__init__(vendor, model, deviceType, id, offlineReason)

        # add property methods
        for funcName, returnValue in propertyMethods.items():
            assert not hasattr(self, funcName)
            setattr(self, funcName, lambda v=returnValue: v)


class ConnectorImpl(Connector):

    ProbeFailed = VendorImpl.ProbeFailed

    def __init__(self, sysSnapshot, machineOrComponent, inlineData, idx=None, id=None, offlineReason=None):
        if id is None:
            id = inlineData.get("id", "default")
            if "%i" in id:
                id = id.replace("%i", str(idx))

        if offlineReason is None:
            if isinstance(machineOrComponent, Machine):
                pass
            elif isinstance(machineOrComponent, Component):
                if not machineOrComponent.is_online():
                    offlineReason = OfflineReason.POWER_OFF
            else:
                assert False

        # create instance
        super().__init__(ConnectorType(inlineData["connector_type"]), id, offlineReason)

        # add property methods
        for funcName, returnValue in inlineData.get("property_methods", {}).items():
            assert not hasattr(self, funcName)
            setattr(self, funcName, lambda v=returnValue: v)


def _assertMachineInfo(info, modelSet):
    info = info.copy()

    x = info.pop("model")
    assert x not in modelSet
    info.pop("model_name", None)

    info.pop("machine_type")

    machineClass = info.pop("machine_class")

    # components
    for componentInfo in info.pop("components", []):
        componentInfo = componentInfo.copy()

        componentInfo.pop("vendor")
        componentInfo.pop("model")

        assert len(componentInfo) == 0

    # devices
    for deviceInfo in info.pop("devices", []):
        deviceInfo = deviceInfo.copy()

        if "vendor" in deviceInfo:
            deviceInfo.pop("vendor")
            deviceInfo.pop("model")
        else:
            # inline device
            deviceInfo.pop("device_type")

            deviceInfo.pop("id", None)

            deviceClass = deviceInfo.pop("device_class", None)
            if deviceClass is not None:
                # for inline device, deviceClass must be in the same package as the machineClass
                assert deviceClass.split(".")[0] == machineClass.split(".")[0]
            else:
                # this is a dumb device if "device_class" property is not set, and uses the default dumb device class
                pass

            deviceInfo.pop("description", None)

            deviceInfo.pop("property_methods", None)

        deviceInfo.pop("count", None)

        deviceInfo.pop("comment", None)

        assert len(deviceInfo) == 0

    # connectors, all connectors are inline
    for connectorInfo in info.pop("connectors", []):
        connectorInfo = connectorInfo.copy()

        connectorInfo.pop("connector_type")

        connectorInfo.pop("id", None)

        connectorClass = connectorInfo.pop("connector_class", None)
        if connectorClass is not None:
            # for inline connector, connectorClass must be in the same package as the machineClass
            assert connectorClass.split(".")[0] == machineClass.split(".")[0]
        else:
            # this is a dumb connector if "connector_class" property is not set, and uses the default dumb connector class
            pass

        connectorInfo.pop("description", None)

        connectorInfo.pop("property_methods", None)

        connectorInfo.pop("count", None)

        connectorInfo.pop("comment", None)

        assert len(connectorInfo) == 0

    info.pop("property_methods", None)

    info.pop("comment", None)

    assert len(info) == 0


def _assertComponentInfo(info, modelSet):
    info = info.copy()

    x = info.pop("model")
    assert x not in modelSet
    info.pop("model_name", None)

    info.pop("component_type")

    componentClass = info.pop("component_class", None)
    if componentClass is not None:
        assert componentClass != "defualt"
        # only with "dumb_component_class" property set, this component are able to:
        # 1. be a dumb component, "dumb_component_class" property can have value "default" or a class identifier
        # 2. be saved
        dumbComponentClass = info.pop("dumb_component_class", None)
    else:
        # when "component_class" is not set, this is a dumb component which:
        # 1. uses the default dumb component class
        # 2. dumb component has no save scenario
        dumbComponentClass = None

    # devices
    for deviceInfo in info.pop("devices", []):
        deviceInfo = deviceInfo.copy()

        if "vendor" in deviceInfo:
            deviceInfo.pop("vendor")
            deviceInfo.pop("model")
            # this device must be able to be a dumb device when component is able to be a dumb component, but we are not able to check here
        else:
            # inline device
            deviceInfo.pop("device_type")

            deviceInfo.pop("id", None)

            deviceClass = deviceInfo.pop("device_class", None)
            if deviceClass is not None:
                # smart device must not be in dumb component
                assert componentClass is not None
                # for inline device, deviceClass must be in the same package as the componentClass
                assert deviceClass.split(".")[0] == componentClass.split(".")[0]
                # this device must be able to be a dumb device when component is able to be a dumb component, "dumb_device_class" property can have value "default" or a class identifier
                if dumbComponentClass is not None:
                    deviceInfo.pop("dumb_device_class")
            else:
                # this is a dumb device if "device_class" property is not set, and uses the default dumb device class
                pass

            deviceInfo.pop("description", None)

            deviceInfo.pop("property_methods", None)

        deviceInfo.pop("count", None)

        deviceInfo.pop("comment", None)

        assert len(deviceInfo) == 0

    # connectors, all connectors are inline
    for connectorInfo in info.pop("connectors", []):
        connectorInfo = connectorInfo.copy()

        connectorInfo.pop("connector_type")

        connectorInfo.pop("id", None)

        connectorClass = connectorInfo.pop("connector_class", None)
        if connectorClass is not None:
            # smart connector must not be in dumb component
            assert componentClass is not None
            # for inline connector, connectorClass must be in the same package as the componentClass
            assert connectorClass.split(".")[0] == componentClass.split(".")[0]
            # this connector must be able to be a dumb connector when component is able to be a dumb component, "dumb_connector_class" property can have value "default" or a class identifier
            if dumbComponentClass is not None:
                deviceInfo.pop("dumb_connector_class")
        else:
            # this is a dumb connector if "connector_class" property is not set, and uses the default dumb connector class
            pass

        connectorInfo.pop("description", None)

        connectorInfo.pop("property_methods", None)

        connectorInfo.pop("count", None)

        connectorInfo.pop("comment", None)

        assert len(connectorInfo) == 0

    info.pop("property_methods", None)

    info.pop("comment", None)

    assert len(info) == 0


def _assertDeviceInfo(info, modelSet):
    info = info.copy()

    x = info.pop("model")
    assert x not in modelSet
    info.pop("model_name", None)

    info.pop("device_type")

    deviceClass = info.pop("device_class", None)
    if deviceClass is not None:
        # only with "dumb_device_class" property set, this device can be a dumb device (so that can be used in dumb component), "dumb_device_class" property can have value "default" or a class identifier
        info.pop("dumb_device_class", None)
    else:
        # when "device_class" is not set, this device is a dumb device which uses the default dumb device class
        pass

    info.pop("property_methods", None)

    info.pop("comment", None)

    assert len(info) == 0
